需要预先了解:


1. 切片、map 与字符串的底层实现

这一篇把三种最常用的类型拆到运行时层面:切片头到底存了什么、append 扩容的精确规则、map 的桶结构与渐进式搬迁、字符串为什么不可变。搞懂这些,前面几篇里"看起来反直觉"的行为就都有解释了。


1.1 切片头

切片在运行时是一个三元组:

type slice struct {
	array unsafe.Pointer // 指向底层数组
	len   int            // 长度
	cap   int            // 容量
}

用 unsafe 观察:

s := make([]int, 3, 5)
fmt.Printf("切片: len=%d cap=%d 底层数组指针=%p 首元素地址=%p\n",
	len(s), cap(s), unsafe.SliceData(s), &s[0])
fmt.Println("切片头大小:", unsafe.Sizeof(s))
切片: len=3 cap=5 底层数组指针=0x26fb60e5c5a0 首元素地址=0x26fb60e5c5a0
切片头大小: 24

unsafe.SliceData(Go 1.20+)直接返回底层数组的指针,和首元素地址一致。

注:reflect.SliceHeader 和 reflect.StringHeader 是历史上用来窥探这两个结构的类型,从 Go 1.20 起已标记为废弃——它们的字段是普通整数,GC 不会把它们当作指针,一旦被误用就会导致对象被提前回收。需要拿数据指针时用 unsafe.SliceData / unsafe.StringData。


1.2 切片扩容策略

append 容量不足时调用 growslice,分两步:先算新容量,再向分配器申请内存(向上取整到 size class)。

容量计算规则(Go 1.18 起):

  1. 需要的新容量超过当前容量的两倍,直接取需要值;
  2. 当前容量小于 256,翻倍;
  3. 当前容量不小于 256,按 newcap += (newcap + 3*256) / 4 增长,大约 1.25 倍。

第二步之后还要按元素大小做 size class 取整。实测 []int 从空切片开始 append 到 5000 个元素:

1 2 4 8 16 32 64 128 256 512 848 1280 1792 2560 3408 5120
  • 256 之前是标准翻倍;
  • 256 之后 512 → 848:公式算出 832,按 8 字节元素取整到 6784 字节的 size class,得到 848;
  • 再往后每一步都在公式结果上做同样的取整,所以看起来是 1.25 倍左右的跳跃。

元素大小会改变起点:

[]int         容量序列: 1 2 4 8 16 32 64 128 256 512
[]byte        容量序列: 8 16 32 64 128 256 512
[]point(24B)  容量序列: 1 2 4 8 16 32 64 128 256 512

[]byte 第一次扩容就拿到 8 个字节(最小 size class),[]int 第一次拿到 1 个元素(8 字节),[]point 每个元素 24 字节,第一次也是 1 个。

实践结论:容量能预估就预分配,make([]T, 0, n) 一次分配胜过十几次扩容加拷贝。


1.3 map 的数据结构

注:下面描述的是 Go 1.23 及更早版本的实现。Go 1.24 起 map 换成了 Swiss table 实现,见后文——对外行为(增删改查复杂度、遍历随机化、value 不可取地址)完全一致,只是内部结构不同。

map 变量本身只是一个指针(8 字节),指向运行时的 hmap:

fmt.Println("map 变量大小:", unsafe.Sizeof(m)) // 8

hmap 的关键字段:

type hmap struct {
	count      int            // 元素个数
	flags      uint8          // 状态标志(是否正在写、是否正在扩容)
	B          uint8          // 桶数量的对数:桶数 = 2^B
	noverflow  uint16         // 溢出桶的近似数量
	hash0      uint32         // 哈希种子,每个 map 不同
	buckets    unsafe.Pointer // 桶数组
	oldbuckets unsafe.Pointer // 扩容时的旧桶数组
	nevacuate  uintptr        // 已搬迁的桶数量
}

每个桶(bmap)能装 8 个键值对:

+-------------------------------+
| tophash [8]uint8              |  每个槽的高 8 位哈希,用于快速筛选
+-------------------------------+
| keys    [8]keyType            |
+-------------------------------+
| values  [8]elemType           |
+-------------------------------+
| overflow *bmap                |  溢出桶指针
+-------------------------------+

tophash 的作用是加速比较:查找时先比 8 位的哈希,只有哈希相同才去比完整的键,避免昂贵的键比较。


1.4 map 的定位流程

key ──hash(hash0)──> 64 位哈希
                       │
                       ├── 低 B 位 ──> 桶编号
                       └── 高 8 位 ──> tophash
                                        │
                                        v
                     在桶内遍历 8 个槽:
                        tophash 相同 → 比较完整 key → 命中
                        8 个都没命中 → 沿 overflow 指针找下一个桶

平均情况下的查找代价是 O(1):一次哈希 + 一次桶内扫描(最多 8 次比较)。

NaN 的怪行为

float64 的 NaN 不等于任何值,包括它自己。map 的查找逻辑里"键相等"用的是 ==,所以用 NaN 写入的键永远查不到:

nan := math.NaN()
m := map[float64]int{nan: 1}
fmt.Println(len(m))    // 1,确实存进去了
fmt.Println(m[nan])    // 0,查不到

这也是为什么不要把浮点数当 map 的键。


1.5 map 的扩容

触发扩容的条件有两个:

条件 说明 扩容方式
元素数 / 桶数 > 6.5 负载因子过高,查找变慢 桶数翻倍(B+1)
溢出桶过多 大量键落在同一个桶,说明哈希分布差 等量扩容(桶数不变)

负载因子 6.5 是"空间"和"时间"的折中:桶装 8 个槽,装到 6.5 个左右就开始变慢。

渐进式搬迁

扩容不是一次性完成的。如果一次要搬迁几百万个元素,停顿会很长。Go 的做法是渐进式搬迁(incremental evacuation):

  • 扩容时先分配新桶数组,oldbuckets 指向旧数组;
  • 之后每次读写都会顺手搬一两个旧桶到新数组;
  • 直到所有旧桶搬完,oldbuckets 被置空。

这也解释了 map 遍历顺序的随机化:遍历时可能同时面对新旧两套桶,起点又被随机化,所以顺序不可预测(见 map 与字符串)。


1.6 Go 1.24+ 的 Swiss table

Go 1.24 把 map 的底层换成了 Swiss table(源自 Abseil 的哈希表设计):

  • 数据按 group 组织,每组 8 个槽;
  • 每组有一个控制字(8 个字节,每个字节对应一个槽),保存 7 位哈希片段或"空/删除"标记;
  • 查找时先用 SIMD 指令一次性比较 8 个控制字节,快速筛出候选槽;
  • 负载因子是 7/8(maxAvgGroupLoad = 7),比 6.5 更高,内存利用率更好;
  • 元素较少时用"小 map"特化,只有一个 group,不额外分配表。

对外行为不变:平均 O(1)、扩容是均摊的、遍历顺序随机、value 不可取地址。


1.7 为什么 map 的 value 不能取地址

m := map[string]int{"a": 1}
p := &m["a"] // 编译错误

从底层看有两个原因:

  1. 元素会移动。扩容时键值对会被搬到新的桶里,如果允许取地址,这个地址随时可能失效;
  2. value 不是"变量"。map 的索引表达式返回的是一个值的副本,而不是一块可寻址的内存。

需要修改结构体字段时,要么整体替换(m[k] = v),要么把 value 类型改成指针(map[K]*V)。


1.8 字符串的底层结构

字符串是一个"指针 + 长度"的两元组:

type stringStruct struct {
	str unsafe.Pointer // 指向只读的字节序列
	len int            // 字节长度
}
str := "hello"
fmt.Printf("字符串: 长度=%d 数据指针=%p\n", len(str), unsafe.StringData(str))
fmt.Println("字符串头大小:", unsafe.Sizeof(str))
字符串: 长度=5 数据指针=0x7ff756e181aa
字符串头大小: 16

几个推论:

  • len(s) 是字节数,不是字符数;
  • 字符串不可变:底层内存是只读的,编译器禁止 s[0] = 'x';
  • 字符串常量存在只读数据段,地址看起来像 0x7ff...;[]byte(s) 转换出来的字符串则在堆上;
  • 切片和字符串可以共享底层数组:string(bytes) 会复制,unsafe.String 不复制(见 反射与 unsafe)。

1.9 string 与 []byte 的转换成本

[]byte(s) 和 string(b) 都会复制一份数据。频繁转换是常见的性能陷阱:

BenchmarkStringToBytes-24    	12358393	        18.92 ns/op	      40 B/op	       2 allocs/op

一次转换就是 40 字节、2 次分配。在循环里做转换会迅速放大开销。需要反复读写时,先把数据转成 []byte 处理完再转回字符串,或者用 bytes.Buffer。


1.10 strings.Builder 的内部机制

strings.Builder 内部就是一个 []byte:

type Builder struct {
	addr *Builder
	buf  []byte
}

WriteString 把字符串追加到 buf 上,String() 用 unsafe 把 buf 直接零拷贝地转成字符串(之后 Builder 会禁止继续写,防止字符串被偷偷改掉)。所以拼接 100 次的开销差别很大:

BenchmarkConcatPlus-24       	   18340	     14219 ns/op	   64024 B/op	      99 allocs/op
BenchmarkConcatBuilder-24    	 1000000	       524.4 ns/op	    1280 B/op	       1 allocs/op

用 + 拼接:99 次分配、64KB 内存、14µs;用 Builder 并 Grow 预分配:1 次分配、1.3KB、0.5µs。循环里拼接字符串一律用 Builder。


1.11 底层视角回顾共享陷阱

数组与切片 里的三个陷阱,现在可以从内存层面解释:

  1. 两个切片互相影响:它们的三元组里 array 指向同一块内存;
  2. append 后分裂:cap 够时写进同一块内存,cap 不够时 growslice 分配新数组,两个切片的 array 从此分道扬镳;
  3. 小切片占住大数组:切片的 cap 仍然指向原数组末尾,GC 通过 array 指针判断可达性,整块数组因此无法回收。

1.12 练习

  1. 用 unsafe.SliceData 和 &s[0] 打印切片在不同 append 阶段的地址,验证"容量足够地址不变、容量不足地址改变"。
  2. 写一个程序打印 []int64、[]string、[]struct{ A, B int } 的容量增长序列,比较元素大小对首次容量的影响。
  3. 用 testing.B 对比 strings.Builder、bytes.Buffer 和 + 三种拼接方式的 ns/op 与 allocs/op。